В этом проекте мы будем оценивать результаты A/B-теста, посмотрим, насколько корректно он был проведён, а также проанализируем его итоги. У нас в распоряжении будут датасет с действиями пользователей, техническое задание и несколько вспомогательных датасетов. Для того, чтобы оценить корректность проведения теста, мы проверим:
ab_project_marketing_events.csv — календарь маркетинговых событий на 2020 год.
Структура файла:
name — название маркетингового события;regions — регионы, в которых будет проводиться рекламная кампания;start_dt — дата начала кампании;finish_dt — дата завершения кампании.final_ab_new_users.csv — пользователи, зарегистрировавшиеся с 7 по 21 декабря 2020 года.
Структура файла:
user_id — идентификатор пользователя;first_date — дата регистрации;region — регион пользователя;device — устройство, с которого происходила регистрация.final_ab_events.csv — действия новых пользователей в период с 7 декабря 2020 по 4 января 2021 года.
Структура файла:
user_id — идентификатор пользователя;event_dt — дата и время покупки;event_name — тип события;details — дополнительные данные о событии. Например, для покупок, purchase, в этом поле хранится стоимость покупки в долларах.final_ab_participants.csv — таблица участников тестов.
Структура файла:
user_id — идентификатор пользователя;ab_test — название теста;group — группа пользователя.# Импортируем библиотеки, которые будут нужны в исследовани
import pandas as pd
import matplotlib.pyplot as plt
%matplotlib inline
import seaborn as sns
from plotly import graph_objects as go
import math as mth
from scipy import stats as st
import numpy as np
import datetime as dt
from datetime import timedelta
# предупредим ошибки типа "warning"
import warnings
warnings.filterwarnings('ignore')
#столбцы и строки полностью, формат округлен
pd.set_option('display.max_columns', None)
pd.options.display.float_format = '{:,.2f}'.format
#вывод значений без сокращений
pd.set_option('display.max_colwidth', -1)
# считаем данные
marketing_events = pd.read_csv('/datasets/ab_project_marketing_events.csv')
new_users = pd.read_csv('/datasets/final_ab_new_users.csv')
events = pd.read_csv('/datasets/final_ab_events.csv')
participants = pd.read_csv('/datasets/final_ab_participants.csv')
# изучим первые 10 строк таблицы marketing_events
display(marketing_events.head(10))
| name | regions | start_dt | finish_dt | |
|---|---|---|---|---|
| 0 | Christmas&New Year Promo | EU, N.America | 2020-12-25 | 2021-01-03 |
| 1 | St. Valentine's Day Giveaway | EU, CIS, APAC, N.America | 2020-02-14 | 2020-02-16 |
| 2 | St. Patric's Day Promo | EU, N.America | 2020-03-17 | 2020-03-19 |
| 3 | Easter Promo | EU, CIS, APAC, N.America | 2020-04-12 | 2020-04-19 |
| 4 | 4th of July Promo | N.America | 2020-07-04 | 2020-07-11 |
| 5 | Black Friday Ads Campaign | EU, CIS, APAC, N.America | 2020-11-26 | 2020-12-01 |
| 6 | Chinese New Year Promo | APAC | 2020-01-25 | 2020-02-07 |
| 7 | Labor day (May 1st) Ads Campaign | EU, CIS, APAC | 2020-05-01 | 2020-05-03 |
| 8 | International Women's Day Promo | EU, CIS, APAC | 2020-03-08 | 2020-03-10 |
| 9 | Victory Day CIS (May 9th) Event | CIS | 2020-05-09 | 2020-05-11 |
# изучим информацию о таблице marketing_events
marketing_events.info()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 14 entries, 0 to 13 Data columns (total 4 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 name 14 non-null object 1 regions 14 non-null object 2 start_dt 14 non-null object 3 finish_dt 14 non-null object dtypes: object(4) memory usage: 576.0+ bytes
# проверим таблицу marketing_events на наличие грубых дубликатов
marketing_events.duplicated().sum()
0
Пропусков и дубликатов в таблице marketing_events нет. Поля с датами на этапе предобработки данных мы заменим на тип даты
# изучим первые 10 строк таблицы new_users
display(new_users.head(10))
| user_id | first_date | region | device | |
|---|---|---|---|---|
| 0 | D72A72121175D8BE | 2020-12-07 | EU | PC |
| 1 | F1C668619DFE6E65 | 2020-12-07 | N.America | Android |
| 2 | 2E1BF1D4C37EA01F | 2020-12-07 | EU | PC |
| 3 | 50734A22C0C63768 | 2020-12-07 | EU | iPhone |
| 4 | E1BDDCE0DAFA2679 | 2020-12-07 | N.America | iPhone |
| 5 | 137119F5A9E69421 | 2020-12-07 | N.America | iPhone |
| 6 | 62F0C741CC42D0CC | 2020-12-07 | APAC | iPhone |
| 7 | 8942E64218C9A1ED | 2020-12-07 | EU | PC |
| 8 | 499AFACF904BBAE3 | 2020-12-07 | N.America | iPhone |
| 9 | FFCEA1179C253104 | 2020-12-07 | EU | Android |
# изучим информацию о таблице new_users
new_users.info()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 61733 entries, 0 to 61732 Data columns (total 4 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 user_id 61733 non-null object 1 first_date 61733 non-null object 2 region 61733 non-null object 3 device 61733 non-null object dtypes: object(4) memory usage: 1.9+ MB
# проверим таблицу new_users на наличие грубых дубликатов
new_users.duplicated().sum()
0
В таблице new_users нет дубликатов и на этапе предобработки данных следует заменить тип столбца 'first_date' на тип datetime
# изучим первые 10 строк таблицы events
display(events.head(10))
| user_id | event_dt | event_name | details | |
|---|---|---|---|---|
| 0 | E1BDDCE0DAFA2679 | 2020-12-07 20:22:03 | purchase | 99.99 |
| 1 | 7B6452F081F49504 | 2020-12-07 09:22:53 | purchase | 9.99 |
| 2 | 9CD9F34546DF254C | 2020-12-07 12:59:29 | purchase | 4.99 |
| 3 | 96F27A054B191457 | 2020-12-07 04:02:40 | purchase | 4.99 |
| 4 | 1FD7660FDF94CA1F | 2020-12-07 10:15:09 | purchase | 4.99 |
| 5 | 831887FE7F2D6CBA | 2020-12-07 06:50:29 | purchase | 4.99 |
| 6 | 6B2F726BFD5F8220 | 2020-12-07 11:27:42 | purchase | 4.99 |
| 7 | BEB37715AACF53B0 | 2020-12-07 04:26:15 | purchase | 4.99 |
| 8 | B5FA27F582227197 | 2020-12-07 01:46:37 | purchase | 4.99 |
| 9 | A92195E3CFB83DBD | 2020-12-07 00:32:07 | purchase | 4.99 |
# Изучим информацию о таблице events
events.info()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 440317 entries, 0 to 440316 Data columns (total 4 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 user_id 440317 non-null object 1 event_dt 440317 non-null object 2 event_name 440317 non-null object 3 details 62740 non-null float64 dtypes: float64(1), object(3) memory usage: 13.4+ MB
# Проверим таблицу на грубые дубликаты
events.duplicated().sum()
0
Грубых дубликатов в таблице events нет. Изучим пропуски в столбце details. Признак даты события следует привести к типу даты на этапе предобработки данных.
# Посмотрим, какие значения принимает столбец 'details'
events['details'].value_counts()
4.99 46362 9.99 9530 99.99 5631 499.99 1217 Name: details, dtype: int64
# сгруппируем события по количеству
events.groupby('event_name').count()
| user_id | event_dt | details | |
|---|---|---|---|
| event_name | |||
| login | 189552 | 189552 | 0 |
| product_cart | 62462 | 62462 | 0 |
| product_page | 125563 | 125563 | 0 |
| purchase | 62740 | 62740 | 62740 |
Мы заметили, что признак 'details' заполнен только для события purchase.
print('Доля пропусков в столбце "details" составляет {:.2%}'.format(1 - (62740 / len(events))))
Доля пропусков в столбце "details" составляет 85.75%
Столбец 'details', согласно описанию данных, содержит сведения о дополнительных данных о событии. Поэтому пропуски здесь ни на что не повлияют. Оставим строки с пропусками как есть.
# изучим первые 10 строк таблицы participants
display(participants.head(10))
| user_id | group | ab_test | |
|---|---|---|---|
| 0 | D1ABA3E2887B6A73 | A | recommender_system_test |
| 1 | A7A3664BD6242119 | A | recommender_system_test |
| 2 | DABC14FDDFADD29E | A | recommender_system_test |
| 3 | 04988C5DF189632E | A | recommender_system_test |
| 4 | 482F14783456D21B | B | recommender_system_test |
| 5 | 4FF2998A348C484F | A | recommender_system_test |
| 6 | 7473E0943673C09E | A | recommender_system_test |
| 7 | C46FE336D240A054 | A | recommender_system_test |
| 8 | 92CB588012C10D3D | A | recommender_system_test |
| 9 | 057AB296296C7FC0 | B | recommender_system_test |
# изучим информацию о таблице participants
participants.info()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 18268 entries, 0 to 18267 Data columns (total 3 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 user_id 18268 non-null object 1 group 18268 non-null object 2 ab_test 18268 non-null object dtypes: object(3) memory usage: 428.3+ KB
# Проверим таблицу participants на наличие грубых дубликатов
participants.duplicated().sum()
0
# Проверим, есть ли дубликаты в столбце в идентификаторами пользователей
participants['user_id'].duplicated().sum()
1602
В таблице participants отсутствуют грубые дубликаты, а вот в столбце 'user_id' 1602 пользователя встречаются дважды. Это может говорить о том, что одни и те же пользователи попадали в разные группы одновременно.
# изменим типы данных на datetime в столбцах, которые были обнаружены на предыдущем шаге
marketing_events['start_dt'] = pd.to_datetime(marketing_events['start_dt'])
marketing_events['finish_dt'] = pd.to_datetime(marketing_events['finish_dt'])
new_users['first_date'] = pd.to_datetime(new_users['first_date'])
events['event_dt'] = pd.to_datetime(events['event_dt'])
# Убедимся, что изменения вступили в силу
for i in [marketing_events, new_users, events]:
i.info()
print()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 14 entries, 0 to 13 Data columns (total 4 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 name 14 non-null object 1 regions 14 non-null object 2 start_dt 14 non-null datetime64[ns] 3 finish_dt 14 non-null datetime64[ns] dtypes: datetime64[ns](2), object(2) memory usage: 576.0+ bytes <class 'pandas.core.frame.DataFrame'> RangeIndex: 61733 entries, 0 to 61732 Data columns (total 4 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 user_id 61733 non-null object 1 first_date 61733 non-null datetime64[ns] 2 region 61733 non-null object 3 device 61733 non-null object dtypes: datetime64[ns](1), object(3) memory usage: 1.9+ MB <class 'pandas.core.frame.DataFrame'> RangeIndex: 440317 entries, 0 to 440316 Data columns (total 4 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 user_id 440317 non-null object 1 event_dt 440317 non-null datetime64[ns] 2 event_name 440317 non-null object 3 details 62740 non-null float64 dtypes: datetime64[ns](1), float64(1), object(2) memory usage: 13.4+ MB
Типы данных в полях 'start_dt' и 'finish_dt' таблицы marketing_events, а также 'first_date' таблицы new_users и 'event_dt' таблицы events приведены к корректным. Перейдём к следующему шагу.
Согласно техническому заданию, нас интересует тест recommender_system_test. Таблица participants содержит не только об этом тесте. Следует начать с того, что мы выделим записи, которые соответствуют интересующему нас тесту - в отдельную переменную.
participants['ab_test'].value_counts()
interface_eu_test 11567 recommender_system_test 6701 Name: ab_test, dtype: int64
participants_actual = participants.query('ab_test == "recommender_system_test"')
display(participants_actual.head(10))
| user_id | group | ab_test | |
|---|---|---|---|
| 0 | D1ABA3E2887B6A73 | A | recommender_system_test |
| 1 | A7A3664BD6242119 | A | recommender_system_test |
| 2 | DABC14FDDFADD29E | A | recommender_system_test |
| 3 | 04988C5DF189632E | A | recommender_system_test |
| 4 | 482F14783456D21B | B | recommender_system_test |
| 5 | 4FF2998A348C484F | A | recommender_system_test |
| 6 | 7473E0943673C09E | A | recommender_system_test |
| 7 | C46FE336D240A054 | A | recommender_system_test |
| 8 | 92CB588012C10D3D | A | recommender_system_test |
| 9 | 057AB296296C7FC0 | B | recommender_system_test |
# В столбце ab_test теперь нет необходимости, так как он содержит единственное значение. Избавимся от него
participants_actual = participants_actual.drop(columns='ab_test')
# Оценим число пользователей в каждой группе
participants_actual['group'].value_counts()
A 3824 B 2877 Name: group, dtype: int64
В тесте recommender_system_test существует 6701 запись о пользователях: к группе А относится 3824 пользователей и 2877 пользователей относится к группе В.
Убедимся, что оно время проведения теста не совпадает с маркетинговыми и другими активностями.
Проверяем дату запуска теста: 2020-12-07 и дату остановки: 2021-01-04
events_test = events.query('user_id in @participants_actual["user_id"]')
events_test.head()
| user_id | event_dt | event_name | details | |
|---|---|---|---|---|
| 5 | 831887FE7F2D6CBA | 2020-12-07 06:50:29 | purchase | 4.99 |
| 17 | 3C5DD0288AC4FE23 | 2020-12-07 19:42:40 | purchase | 4.99 |
| 58 | 49EA242586C87836 | 2020-12-07 06:31:24 | purchase | 99.99 |
| 71 | 2B06EB547B7AAD08 | 2020-12-07 21:36:38 | purchase | 4.99 |
| 74 | A640F31CAC7823A6 | 2020-12-07 18:48:26 | purchase | 4.99 |
# Изучим описание данных столбца 'event_dt'
events_test['event_dt'].describe()
count 24698 unique 16523 top 2020-12-09 19:01:05 freq 6 first 2020-12-07 00:05:57 last 2020-12-30 12:42:57 Name: event_dt, dtype: object
Мы видим, что дата остановки теста более рання, чем в тех задании: 30 декабря 2020 вместо 4 января 2021. Проводить тест в праздники особого смысла не имеет, так как это время аномалий, а аномалии портят данные.
Проверим дату остановки набора новых пользователей: 2020-12-21
new_users_test = new_users.query('user_id in @participants_actual["user_id"]').sort_values(by='first_date')
display(new_users_test.head())
| user_id | first_date | region | device | |
|---|---|---|---|---|
| 0 | D72A72121175D8BE | 2020-12-07 | EU | PC |
| 3689 | 9B1F030CA887DAE6 | 2020-12-07 | EU | iPhone |
| 3695 | 07F90C8E398C5655 | 2020-12-07 | EU | PC |
| 3697 | E3E47B8FBDF4EF63 | 2020-12-07 | EU | Android |
| 3699 | 9C2D0067A991213E | 2020-12-07 | EU | PC |
new_users_test['first_date'].describe()
count 6701 unique 15 top 2020-12-21 00:00:00 freq 723 first 2020-12-07 00:00:00 last 2020-12-21 00:00:00 Name: first_date, dtype: object
Дата начала набора и дата окончания набора соответствуют нашему ТЗ — это 7 и 21 декабря 2020 года, соответственно.
Мы проводили тест в период с 7 декабря 2020 по 4 января 2021 (на самом деле по 30 декабря, в чем мы убедились на прошлом шаге). Пора проверить, совпадает ли проведение теста с меркетинговыми и другими активностями.
start_dt = pd.to_datetime('2020-12-07', format='%Y-%m-%d')
end_dt = pd.to_datetime('2021-01-04', format='%Y-%m-%d')
marketing_events[marketing_events['regions'].str.contains("EU")].query(
'@end_dt >= start_dt >= @start_dt or @end_dt >= finish_dt >= @start_dt'
)
| name | regions | start_dt | finish_dt | |
|---|---|---|---|---|
| 0 | Christmas&New Year Promo | EU, N.America | 2020-12-25 | 2021-01-03 |
Когда проводили тест, в Европе и Северной Америке (регионы EU, N.America) происходила активность 'Christmas&New Year Promo'. Это усложняет нашу задачу: ведь в период проведения активности 'Christmas&New Year Promo' становится совершенно непонятно, с чем могут быть связаны улучшения — с маркетинговой акцией или с нашим тестом.
Посмотрим, как пересекаются пользователи в параллельных тестах
duplicated_users = participants.groupby('user_id').agg({'ab_test': ['nunique', 'unique']})
duplicated_users.columns = ['groups', 'group_names']
duplicated_users = duplicated_users.query('groups > 1').reset_index()
display(duplicated_users.head())
| user_id | groups | group_names | |
|---|---|---|---|
| 0 | 001064FEAAB631A1 | 2 | [recommender_system_test, interface_eu_test] |
| 1 | 00341D8401F0F665 | 2 | [recommender_system_test, interface_eu_test] |
| 2 | 003B6786B4FF5B03 | 2 | [recommender_system_test, interface_eu_test] |
| 3 | 0082295A41A867B5 | 2 | [recommender_system_test, interface_eu_test] |
| 4 | 00E68F103C66C1F7 | 2 | [recommender_system_test, interface_eu_test] |
duplicated_users.info()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 1602 entries, 0 to 1601 Data columns (total 3 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 user_id 1602 non-null object 1 groups 1602 non-null int64 2 group_names 1602 non-null object dtypes: int64(1), object(2) memory usage: 37.7+ KB
Есть 1602 уникальных пользователя, участвовавших одновременно в двух параллельно проводимых тестах: в том, который мы оцениваем — recommender_system_test и в ещё одном — interface_eu_test. Этих пользователей нам придётся удалить, потому что их наличие в наборе данных затруднит (и даже сделает невозможным) понимание того, какие события в датасете events относились к нашему тесту, а какие к параллельному.
double_group_users = participants.query('ab_test == "recommender_system_test" ').groupby('user_id').agg({'group': ['nunique', 'unique']})
double_group_users.columns = ['groups', 'group_names']
double_group_users = double_group_users.query('groups > 1')
display(double_group_users.head())
len(double_group_users)
| groups | group_names | |
|---|---|---|
| user_id |
0
Пользователи внутри групп теста не пересекаются
group_perc = participants_actual.groupby('group').count()
group_perc['user_id'].sum()
group_perc['group_%'] = group_perc['user_id']*100 / group_perc['user_id'].sum()
display(group_perc)
| user_id | group_% | |
|---|---|---|
| group | ||
| A | 3824 | 57.07 |
| B | 2877 | 42.93 |
Группы распределены неравномерно: группа A содержит 57% участников, а группа B — 42,93%
# Посмотрим, как распределено количество регистраций пользователей в тесте
sns.countplot(data=new_users_test, y=new_users_test['first_date'].dt.date).set(
title = "Распределение регистраций пользователей теста",
xlabel = "Количество регистраций",
ylabel = "Дата регистрации")
[Text(0.5, 1.0, 'Распределение регистраций пользователей теста'), Text(0.5, 0, 'Количество регистраций'), Text(0, 0.5, 'Дата регистрации')]
Можно заметить пиковое значение количества регистраций раз в семь дней. Обратившись к календарю за 2020-й год, можно понять, что эти дни являются понедельниками. Эффект понедельников в действии :)
Согласно ТЗ, ожидаемое количество участников равно 6000, а из Европы ожидается 15% новых пользователей от общего количества зарегистрированных пользователей в этом регионе.
# Оценим, как распределяются пользователи в тесте по регионам.
new_users_test['region'].value_counts()
EU 6351 N.America 223 APAC 72 CIS 55 Name: region, dtype: int64
# смотрим, сколько всего новых пользователей из Европы
total_users_EU = new_users[(new_users['region'] == 'EU') & (new_users['first_date'] <= end_dt) & (new_users['first_date'] >= start_dt)]['region'].count()
print('Количество новых зарегистрировавшихся пользователей из Европы:', total_users_EU)
Количество новых зарегистрировавшихся пользователей из Европы: 46270
new_users
| user_id | first_date | region | device | |
|---|---|---|---|---|
| 0 | D72A72121175D8BE | 2020-12-07 | EU | PC |
| 1 | F1C668619DFE6E65 | 2020-12-07 | N.America | Android |
| 2 | 2E1BF1D4C37EA01F | 2020-12-07 | EU | PC |
| 3 | 50734A22C0C63768 | 2020-12-07 | EU | iPhone |
| 4 | E1BDDCE0DAFA2679 | 2020-12-07 | N.America | iPhone |
| ... | ... | ... | ... | ... |
| 61728 | 1DB53B933257165D | 2020-12-20 | EU | Android |
| 61729 | 538643EB4527ED03 | 2020-12-20 | EU | Mac |
| 61730 | 7ADEE837D5D8CBBD | 2020-12-20 | EU | PC |
| 61731 | 1C7D23927835213F | 2020-12-20 | EU | iPhone |
| 61732 | 8F04273BB2860229 | 2020-12-20 | EU | Android |
61733 rows × 4 columns
total_of_plan = int(new_users[new_users['region'] == 'EU']['region'].count()*0.15)
print('Мы ожидаем 15% новых пользователей из Eвропы для аудитории теста, что составляет:', total_of_plan)
Мы ожидаем 15% новых пользователей из Eвропы для аудитории теста, что составляет: 6940
# всего новых пользователей из Европы в нашем тесте
real_total = new_users_test.groupby('region')['region'].count()['EU']
print('На самом деле новых пользователей из Европы в нашем в тесте:', real_total)
На самом деле новых пользователей из Европы в нашем в тесте: 6351
percent_of_real = real_total / total_users_EU
print('Реальная доля новых пользователей из Европы получается {:.2%}'.format(percent_of_real))
Реальная доля новых пользователей из Европы получается 13.73%
Состав аудитории теста не соответствует требованиям технического задания: мы ожидали 6000 участников, а получили 6940. Реальное количество участников 6701 пользователь, из которых 6351 из Европы и 350 — из других регионов. Нужное количество пользователей из Европы у нас не набрано: оно составило не 15%, а 13.73%. Нам придётся удалить пользователей из других регионов, ведь они не были предусмотрены техническим заданием.
real_total / new_users.query('first_date <= @new_users_test.first_date.max() and \
first_date >= @new_users_test.first_date.min() and region.str.contains("EU")')['region'].count()
0.15
# добавим немного автоматизации в процесс очистки данных
def clear_users(df_for_clear):
print('Количество пользователей теста до чистки:', df_for_clear['user_id'].nunique())
user_not_EU = new_users_test.query('region != "EU"')['user_id']
df_clear = df_for_clear.query('user_id not in @duplicated_users["user_id"]')
df_clear = df_clear.query('user_id not in @user_not_EU')
print('Количество пользователей теста после чистки:', df_clear['user_id'].nunique())
print()
return df_clear
# используем функцию
new_users_test_clear = clear_users(new_users_test)
events_test_clear = clear_users(events_test)
participants_test_clear = clear_users(participants_actual)
Количество пользователей теста до чистки: 6701 Количество пользователей теста после чистки: 4749 Количество пользователей теста до чистки: 3675 Количество пользователей теста после чистки: 2594 Количество пользователей теста до чистки: 6701 Количество пользователей теста после чистки: 4749
Итак, мы удалили из данных пользователей, которые попали в оба теста и тех, кто не из европейского региона, остается 4749 пользователей и активных (тех, что совершали хоть одно действие после регистрации) среди них уже не 3675 пользователей, а 2594. Посмотрим на распределение регистраций пользователей между группами тестирования.
group_perc_clear = participants_test_clear.groupby('group').count()
group_perc_clear['user_id'].sum()
group_perc_clear['percent_of_group'] = group_perc_clear['user_id']*100 / group_perc_clear['user_id'].sum()
group_perc_clear
| user_id | percent_of_group | |
|---|---|---|
| group | ||
| A | 2713 | 57.13 |
| B | 2036 | 42.87 |
# посмотрим, как распределяются пользователи после чистки
sns.countplot(data=new_users_test_clear, y=new_users_test_clear['first_date'].dt.date).set(
title = "Распределение регистраций пользователей теста",
xlabel = "Количество регистраций",
ylabel = "Дата регистрации")
[Text(0.5, 1.0, 'Распределение регистраций пользователей теста'), Text(0.5, 0, 'Количество регистраций'), Text(0, 0.5, 'Дата регистрации')]
После чистки пользователей пропорции количества пользователей между группами и распределение зарегистрированных во времени почти не изменились, таким образом, удаление не повлияло на общий вид распределения. Пики приходятся на понедельники. Как и до чистки.
Согласно техническому заданию, мы должны посчитать воронку на 14 день после регистрации. Посчитаем предельную дату когорты, которая успела прожить 14 дней.
# считаем предельную дату когорты, время «жизни» которой составило 14 дней
final_date = dt.datetime(2020, 12, 30).date()
horisont_days = 14
date_reg = pd.to_datetime(final_date - timedelta(days=horisont_days - 1), format='%Y-%m-%d')
date_reg
Timestamp('2020-12-17 00:00:00')
В наших данных 14 дней прожили пользователи, зарегистрировавшиеся с 7 декабря по 17 декабря 2020 года. Начиная с регистрации от 18 декабря данные неполные. Более того, по ТЗ мы анализируем воронку на 14 день жизни — события старше 14 лайфтайма нам также не нужны.
Сформируем переменные по первоначальным данным теста и "чистым" с учетом выявленных нарушений при тестировании, состоящие из событий теста events_system_test, к которым добавим дату регистрации пользователя, группу теста и лайфтайм. Удалим пользователей, зарегистрированных с 18 по 30 декабря 2020 и события, начиная с 14 лайфтайма (с 15 дня «жизни» пользователя).
def events_and_profiles (df_events, df_new_users, df_participants):
#добавляем к событиям группу теста
base = df_events.merge(df_participants, how='left', on=['user_id'] )
#добавляем дату регистрации
base = base.merge(df_new_users[['user_id', 'first_date']], how='left', on=['user_id'])
#добавляем колонку с датой регистрации и датой события без времени
base['event_date'] = base['event_dt'].dt.date
base['first_date'] = base['first_date'].dt.date
#добавляем колонку с лайфтаймом
base['lifetime'] = (base['event_date'] - base['first_date']).dt.days
#отрезаем события больше 14 лайфтайма
base = base[base['lifetime']<=13]
#отрезаем пользователей, не проживших 14 дней (даты когорт больше 17.12.2020)
#base = base[base['first_date']<= date_reg]
return base
Для того, чтобы сравнивать результаты теста, будем вести параллельно сразу два расчета: по фактическим данным групп и по "чистым", с удаленными пользователями двух групп и пользователями из других регионов.
#формируем таблицы
# по исходным данным
events_and_profiles_base = events_and_profiles(events_test, new_users_test, participants_actual)
#по "чистым" данным
events_and_profiles_clear = events_and_profiles(
events_test_clear, new_users_test_clear, participants_test_clear
)
display(events_and_profiles_base.head(), f'Количество событий в исходном датасете: {len(events_and_profiles_base)}')
display(events_and_profiles_clear.head(), f'Количество событий в очищенном датасете: {len(events_and_profiles_clear)}')
| user_id | event_dt | event_name | details | group | first_date | event_date | lifetime | |
|---|---|---|---|---|---|---|---|---|
| 0 | 831887FE7F2D6CBA | 2020-12-07 06:50:29 | purchase | 4.99 | A | 2020-12-07 | 2020-12-07 | 0 |
| 1 | 3C5DD0288AC4FE23 | 2020-12-07 19:42:40 | purchase | 4.99 | A | 2020-12-07 | 2020-12-07 | 0 |
| 2 | 49EA242586C87836 | 2020-12-07 06:31:24 | purchase | 99.99 | B | 2020-12-07 | 2020-12-07 | 0 |
| 3 | 2B06EB547B7AAD08 | 2020-12-07 21:36:38 | purchase | 4.99 | A | 2020-12-07 | 2020-12-07 | 0 |
| 4 | A640F31CAC7823A6 | 2020-12-07 18:48:26 | purchase | 4.99 | B | 2020-12-07 | 2020-12-07 | 0 |
'Количество событий в исходном датасете: 23856'
| user_id | event_dt | event_name | details | group | first_date | event_date | lifetime | |
|---|---|---|---|---|---|---|---|---|
| 0 | 831887FE7F2D6CBA | 2020-12-07 06:50:29 | purchase | 4.99 | A | 2020-12-07 | 2020-12-07 | 0 |
| 1 | 3C5DD0288AC4FE23 | 2020-12-07 19:42:40 | purchase | 4.99 | A | 2020-12-07 | 2020-12-07 | 0 |
| 2 | 49EA242586C87836 | 2020-12-07 06:31:24 | purchase | 99.99 | B | 2020-12-07 | 2020-12-07 | 0 |
| 3 | A640F31CAC7823A6 | 2020-12-07 18:48:26 | purchase | 4.99 | B | 2020-12-07 | 2020-12-07 | 0 |
| 4 | A9908F62C41613A8 | 2020-12-07 11:26:47 | purchase | 9.99 | B | 2020-12-07 | 2020-12-07 | 0 |
'Количество событий в очищенном датасете: 16918'
print('активные пользователи в исходных данных: ', events_and_profiles_base.groupby('group')['user_id'].nunique())
print('')
print('активные пользователи в очищенных данных: ', events_and_profiles_clear.groupby('group')['user_id'].nunique())
активные пользователи в исходных данных: group A 2747 B 928 Name: user_id, dtype: int64 активные пользователи в очищенных данных: group A 1939 B 655 Name: user_id, dtype: int64
Посчитаем общее количество пользователей в группах теста (активных и неактивных — тех, что не совершили ни одного действия) после удаления зарегистрированных после 17 декабря.
#в исходном датасете:
#все пользователи(активные и неактивные)
user_for_fannel_base = new_users_test.query('first_date <= @date_reg')['user_id']
#все пользователи по группам
group_for_funnel_base = participants_actual.query('user_id in @user_for_fannel_base ').groupby('group').count().T
#в очищенном датасете
#все пользователи(активные и неактивные)
user_for_fannel_base_clear = new_users_test_clear.query('first_date <= @date_reg')['user_id']
#все пользователи по группам
group_for_funnel_clear = participants_test_clear.query('user_id in @user_for_fannel_base_clear ')\
.groupby('group').count().T
display("по исходным данным: ", group_for_funnel_base, "по очищенным данным: ", group_for_funnel_clear)
'по исходным данным: '
| group | A | B |
|---|---|---|
| user_id | 2674 | 1999 |
'по очищенным данным: '
| group | A | B |
|---|---|---|
| user_id | 1889 | 1396 |
Мы удалили события свыше 14-го лайфтайма, потому как эти события на самом деле не представляют для нас как для исследователей интереса.
В исходной группе у нас осталось 4763 пользователя (группа А 2674, группа В 1999 пользователей), в "очищенной" — 3285 (группа А - 1889, группа В - 1396)
def funnels (events_and_profiles, group_df):
#по исходным данным:
#группируем уникальных пользователей по событиям
funnel = events_and_profiles.pivot_table(
index = 'event_name', columns = 'group', values = 'user_id', aggfunc = 'nunique')
#удаляем строку login
funnel.drop('login', inplace=True)
#добавляем строку с общим количеством пользователей групп, включая неактивных
funnel = pd.concat([funnel, group_df]).sort_values(by='B', ascending=False)
#добавляем расчетные колонки % событий от события регистрации
funnel['A_%_reg'] = funnel['A'] * 100 / funnel.loc['user_id','A']
funnel['B_%_reg'] = funnel['B'] * 100 / funnel.loc['user_id','B']
funnel['delta_%_reg'] = funnel['B_%_reg'] - funnel['A_%_reg']
#добавляем расчетные колонки % конверсии от предыдущего события
funnel['A_shift'] = funnel['A'].shift(1, axis=0)
funnel['A_convers'] = funnel['A'] * 100 / funnel['A_shift']
funnel['B_shift'] = funnel['B'].shift(1, axis=0)
funnel['B_convers'] = funnel['B'] * 100 / funnel['B_shift']
funnel['delta_%_convers'] = funnel['B_convers'] - funnel['A_convers']
funnel.drop(['A_shift', 'B_shift'], inplace=True, axis = 1)
funnel.rename(index = {'user_id':'user_reg'},inplace=True)
return funnel
#строим данные для воронок
funnel_base = funnels(events_and_profiles_base,group_for_funnel_base )
funnel_clear = funnels(events_and_profiles_clear,group_for_funnel_clear )
display("Воронка по исходным данным", funnel_base, "Воронка по очищенным данным", funnel_clear)
'Воронка по исходным данным'
| group | A | B | A_%_reg | B_%_reg | delta_%_reg | A_convers | B_convers | delta_%_convers |
|---|---|---|---|---|---|---|---|---|
| user_reg | 2674 | 1999 | 100.00 | 100.00 | 0.00 | NaN | NaN | NaN |
| product_page | 1780 | 523 | 66.57 | 26.16 | -40.40 | 66.57 | 26.16 | -40.40 |
| purchase | 872 | 256 | 32.61 | 12.81 | -19.80 | 48.99 | 48.95 | -0.04 |
| product_cart | 824 | 255 | 30.82 | 12.76 | -18.06 | 94.50 | 99.61 | 5.11 |
'Воронка по очищенным данным'
| group | A | B | A_%_reg | B_%_reg | delta_%_reg | A_convers | B_convers | delta_%_convers |
|---|---|---|---|---|---|---|---|---|
| user_reg | 1889 | 1396 | 100.00 | 100.00 | 0.00 | NaN | NaN | NaN |
| product_page | 1265 | 367 | 66.97 | 26.29 | -40.68 | 66.97 | 26.29 | -40.68 |
| purchase | 613 | 191 | 32.45 | 13.68 | -18.77 | 48.46 | 52.04 | 3.59 |
| product_cart | 589 | 184 | 31.18 | 13.18 | -18.00 | 96.08 | 96.34 | 0.25 |
#воронки в разрезе групп
def funnel_graf(funnel_df):
fig = go.Figure()
fig.update_layout(
title={'text' :'Воронка событий по группам A и B', 'x':0.55, 'xanchor': 'center'})
for event in ['A', 'B']:
fig.add_trace(go.Funnel(
name = event,
y=[
'Регистрация пользователей',
'Страница товара',
'Корзина',
'Покупка',
],
x=funnel_df[event],
))
fig.show()
# Строим воронку по исходным данным
funnel_graf(funnel_base)
# Строим воронку по очищенным данным
funnel_graf(funnel_clear)
Если смотреть по воронкам, то кажется, что эффекта от изменений нет вовсе, то есть он отсутствует. Проведём подробный анализ чуть позже, когда коснёмся изменений конверсии в воронке на разных этапах.
В результате оценки корректности проведения теста установлено следущее:
К заданному тесту recommender_system_test относится 6701 запись о пользователях: 3824 в группе А и 2877 в группе В.
Дата начала и окончания набора пользователей 07 декабря и 21 декабря соответствует техническому заданию.
Тест проводился с 7 по 30 декабря 2020 года. Дата остановки теста меньше, чем указано в тех.задании: 30 декабря 2020 вместо 4 января 2021. Однозначно повлияли новогодние праздники.
В момент проведения теста в Европе проводилась промо-акция 'Christmas&New Year Promo'. Понять, что именно изменения вызывают улучшения, а не параллельно проводимая акция, в таком случае крайне затруднительно.
Выявлены 1602 уникальных пользователя, которые участвовали в проверяемом (recommender_system_test) и конкурирующем (interface_eu_test) тестах одновременно.
Внутри групп теста пересечений пользователей нет
Группы разбиты неравномерно: в контрольной 57% пользователей, в тестовой 43%.
Состав аудитории теста: исходное количество участников — 6701, из которых 6351 из Европы и 350 из других регионов.
Необходимо удалить пользователей из других регионов, которые не были предусмотрены техническим заданием.
Согласно ТЗ аудитория должна состоять из 15% новых пользователей из Европы, расчетное количество участников должно быть равно 6000 пользователей. Фактическое количество участников оказалось равным 6940 человек.
В рамках подготовки к оценке результата теста были удалены пользователи из когорт, не «проживших» полных 14 дней, а также события выше 14 лайфтайма.
В исходной группе у нас осталось 4763 пользователя (группа А 2674, группа В 1999 пользователей), в "очищенной" — 3285 (группа А - 1889, группа В - 1396)
Эффект от изменений, судя по воронке, не достигнут: конверсия регистрации в покупку в группе В упала в 2 раза по сравнению с группой А. К этому моменту мы ещё вернёмся.
В наших датасетах events_and_profiles_base и events_and_profiles_active представлены данные о пользователях, которые проявили хоть какую-то активность.
Но общее количество пользователей в группах больше на число пользователей, которые зарегистрировались, но не совершили больше ни одного действия. Общее количество пользователей в разрезе групп посчитано в переменных group_for_funnel, group_for_funnel_clear.
Для того, чтобы посчитать среднее количество событий на пользователя в каждой группе, нужно учесть две категории клиентов: тех, что совершали события и те, события которых равны нулю, иначе среднее по группе исказится.
Чтобы построить распределение количества событий на пользователя, нужно сгруппировать количество событий по каждому пользователю из таблицы событий и нулей, которые мы добавим в выборку соразмерно количеству неактивных пользователей.
Рассчитаем количество неактивных пользователей.
def activ_zero (events_and_profiles, group_for_funnel):
#количество активных пользователей в группах
users = events_and_profiles.groupby('group')['user_id'].nunique().to_frame().T
users.rename(index={'user_id':'user_activ'}, inplace=True)
#добавляем общее количество пользователей по группам
users = pd.concat([group_for_funnel, users]).T
#считаем количество неактивных пользователей
users['user_zero'] = users['user_id'] - users['user_activ']
#считаем % неактивных пользователей в группах
users['user_zero_%'] = users['user_zero'] * 100 / users['user_id']
users.rename(columns={'user_id':'user_total'}, inplace=True)
return users
user_base = activ_zero(events_and_profiles_base, group_for_funnel_base)
user_clear = activ_zero(events_and_profiles_clear, group_for_funnel_clear)
display("в исходных данных", user_base,"в очищенных данных", user_clear)
'в исходных данных'
| user_total | user_activ | user_zero | user_zero_% | |
|---|---|---|---|---|
| group | ||||
| A | 2674 | 2747 | -73 | -2.73 |
| B | 1999 | 928 | 1071 | 53.58 |
'в очищенных данных'
| user_total | user_activ | user_zero | user_zero_% | |
|---|---|---|---|---|
| group | ||||
| A | 1889 | 1939 | -50 | -2.65 |
| B | 1396 | 655 | 741 | 53.08 |
Нами было получено количество пользователей с нулевым количеством событий в каждой группе. Пропорции активных и неактивных пользователей в группах сильно отличаются: неактивных в группе А 40%, а в группе B — 66%. Это уже сигнал в сторону неэффективности изменений, которые тестируются в группе В.
Сформируем Series для визуализации распределения из сгруппированных по количеству событий пользователей и добавим к ним пользователей с нулевым количеством событий
def event_count(events_and_profiles, user_group_df, group_test):
events_count_df = events_and_profiles.query('group == @group_test').groupby('user_id')['event_name'].count()
events_count_df = pd.concat(
[events_count_df,
pd.Series(0, index=np.arange(0, user_group_df.loc[group_test,'user_zero']))
])
return events_count_df
def distribution_users(sampleA, sampleB):
#расчет среднего
print("Среднее количество событий на пользователя в группе А:", round(sampleA.mean()))
print("Среднее количество событий на пользователя в группе B:", round(sampleB.mean()))
#графики распределения
plt.figure(figsize=(16, 5))
ax1 = plt.subplot(1, 2, 1)
sampleA.hist(bins=20).set(title='Распределение событий пользователей группы А',
xlabel = "Количество событий на пользователя")
ax2 = plt.subplot(1, 2, 2)
sampleB.hist(bins=25).set(title='Распределение событий пользователей группы B',
xlabel = "Количество событий на пользователя")
plt.show()
plt.figure(figsize=(18,5))
plt.subplot(1, 2, 1)
sns.boxplot(sampleA).set(title='Распределение событий пользователей группы A',
xlabel = "Количество событий на пользователя")
plt.subplot(1, 2, 2)
sns.boxplot(sampleB).set(title='Распределение событий пользователей группы B',
xlabel = "Количество событий на пользователя")
plt.xticks(rotation=45)
plt.show()
Посмотрим распределение количества событий на пользователя и проведем тест на равенсто событий.
Наши гипотезы:
Выборки большие, уровень критической значимости примем равным 0.01, применим тест Манна-Уитни.
sampleA_base = event_count(events_and_profiles_base, user_base, "A" )
sampleB_base = event_count(events_and_profiles_base, user_base, "B" )
#расчет относительной разницы количества клиента группы В относительно группы А
print("Разница в количестве событий группы В и группы А {0:.2%}".format((sampleB_base.mean() / sampleA_base.mean() - 1)))
#расчет p-value критерия Манна-Уитни
print("p_value:{0:.3f}".format(st.mannwhitneyu(sampleA_base, sampleB_base, True, 'two-sided')[1]))
distribution_users(sampleA_base, sampleB_base)
Разница в количестве событий группы В и группы А -63.05% p_value:0.000 Среднее количество событий на пользователя в группе А: 7 Среднее количество событий на пользователя в группе B: 3
Далее оценим распределение количества событий пользователей в очищенных данных.
sampleA_clear = event_count(events_and_profiles_clear, user_clear, "A" )
sampleB_clear = event_count(events_and_profiles_clear, user_clear, "B" )
#расчет относительной разницы количества клиента группы В относительно группы А
print("Разница в количестве событий группы В и группы А {0:.2%}".format((sampleB_clear.mean() / sampleA_clear.mean() - 1)))
#расчет p-value критерия Манна-Уитни
print("p_value:{0:.3f}".format(st.mannwhitneyu(sampleA_clear, sampleB_clear, True, 'two-sided')[1]))
distribution_users(sampleA_clear, sampleB_clear)
Разница в количестве событий группы В и группы А -63.19% p_value:0.000 Среднее количество событий на пользователя в группе А: 7 Среднее количество событий на пользователя в группе B: 3
Мы уже подчёркивали, что пропорции активных и неактивных пользователей в группах сильно отличаются: неактивных в группе А 40%, а в группе B — 66%. Кроме того, в группе А пик приходится на 7 событий, а в группе В - на 4 и 6. Соответственно, среднее значение количества событий в группе В в 2 раза меньше, чем в группе А (2 против 4)
Отличается медиана: в группе А она на уровне 2.5 событий, а выбросы начинаются с 15 событий. В группе В медиана равна 0, а выбросы начинаются уже с 7 событий.
Разница в количестве событий на человека между группами составила 53%, соответственно проведенный тест показал предсказуемый результат: гипотеза о равенстве событий между группами не подтвердилась, выборки не равны.
Далее для понимания поведения только активных пользователей посчитаем среднее значение количества событий только между ними.
print("Среднее количество событий на пользователя в группе А:")
print(round(events_and_profiles_base.query('group == "A"').groupby('user_id')['event_name'].count().agg(['mean', 'median'])))
print("Среднее количество событий на пользователя в группе B:")
print(round(events_and_profiles_base.query('group == "B"').groupby('user_id')['event_name'].count().agg(['mean', 'median'])))
Среднее количество событий на пользователя в группе А: mean 7.00 median 6.00 Name: event_name, dtype: float64 Среднее количество событий на пользователя в группе B: mean 5.00 median 4.00 Name: event_name, dtype: float64
Если события распределить между активными пользователями, среднее в группе А составляет 7, а в группе В 6 событий, а медианные значения соответственно 6 и 5 событий. Группа А в целом активнее группы В.
plt.figure(figsize=(18,6))
#plt.subplot(1, 2, 1)
a = sns.countplot(data=events_and_profiles_base[events_and_profiles_base['group']=='A'], x="event_date", color='blue')
for rect, label in zip(a.patches, events_and_profiles_base.groupby("event_date")['group'].value_counts()):
plt.text(
rect.get_x() + rect.get_width() / 2,
rect.get_height(),
label,
ha="center",
va="bottom"
)
'''b = sns.countplot(data=events_and_profiles_base[events_and_profiles_base['group']=='B'], x="event_date", color='orange')
for rect, label in zip(b.patches, events_and_profiles_base["event_date"].value_counts()):
plt.text(
rect.get_x() + rect.get_width() / 2,
rect.get_height(),
label,
ha="center",
va="bottom"
)'''
plt.title('Общее распределение событий по исходным данным')
plt.xlabel('Дата события')
plt.ylabel('Количество событий')
plt.xticks(rotation=45)
plt.show()
#plt.subplot(1, 2, 2)
plt.figure(figsize=(18,6))
b = sns.countplot(data=events_and_profiles_clear, x="event_date", hue='group')
plt.title('Общее распределение событий по очищенным данным')
plt.xlabel('Дата события')
plt.ylabel('Количество событий')
plt.xticks(rotation=45)
plt.tight_layout()
plt.show()
На графиках распределения событий по дням у группы А можно увидеть резкий пик количества событий с 13 по 16 декабря. У группы В он тоже есть, но не такой значительный и резкий: мы помним, что в группе В неактивных пользователей больше половины.
Скорее всего причина всплеска заключается в предстоящих новогодних праздниках, поскольку это пора массовых покупок для подарков. В дальнейшем можно наблюдать постепенное снижение активности пользователей, которая замедляется с началом промоакции Christmas&New Year Promo с 25 декабря, но вскоре она всё равно падает.
display(
"Воронка по исходным данным", funnel_base,
"Воронка по очищеным данным", funnel_clear)
'Воронка по исходным данным'
| group | A | B | A_%_reg | B_%_reg | delta_%_reg | A_convers | B_convers | delta_%_convers |
|---|---|---|---|---|---|---|---|---|
| user_reg | 2674 | 1999 | 100.00 | 100.00 | 0.00 | NaN | NaN | NaN |
| product_page | 1780 | 523 | 66.57 | 26.16 | -40.40 | 66.57 | 26.16 | -40.40 |
| purchase | 872 | 256 | 32.61 | 12.81 | -19.80 | 48.99 | 48.95 | -0.04 |
| product_cart | 824 | 255 | 30.82 | 12.76 | -18.06 | 94.50 | 99.61 | 5.11 |
'Воронка по очищеным данным'
| group | A | B | A_%_reg | B_%_reg | delta_%_reg | A_convers | B_convers | delta_%_convers |
|---|---|---|---|---|---|---|---|---|
| user_reg | 1889 | 1396 | 100.00 | 100.00 | 0.00 | NaN | NaN | NaN |
| product_page | 1265 | 367 | 66.97 | 26.29 | -40.68 | 66.97 | 26.29 | -40.68 |
| purchase | 613 | 191 | 32.45 | 13.68 | -18.77 | 48.46 | 52.04 | 3.59 |
| product_cart | 589 | 184 | 31.18 | 13.18 | -18.00 | 96.08 | 96.34 | 0.25 |
Согласно ТЗ ожидаемым эффектом от тестируемых изменений является улучшение каждой метрики как минимум на 10% за 14 дней с момента регистрации. Имеем по факту:
Если смотреть конверсию регистрации в корзину, то в целом она уменьшилась на почти 9% в исходных данных (с 18% до 9%) и на 8% (с 17% до 9%) в очищенных.
Больше всего пользователей теряется на этапе перехода к странице товара: до нее доходит только 38% пользователей группы А и 19% пользователей группы В. Но те, кто доходит до корзины, в подавляющем большинстве платит за покупку. Можно заметить, что в группе В есть 3% увеличение пользователей, дошедших до корзины, но в тоже время высок процент тех, кто покупку не оплатил.
Вывод: результатом тестирования не подтвердилось увеличение конверсии на каждом этапе: конверсия увеличилась только на этапе перехода с регистрации на страницу товара.
Проверим наши данные.
Разница в размерах групп:
display('Размеры групп в исходных данных', group_for_funnel_base,'Размеры групп в очищенных данных', group_for_funnel_clear)
'Размеры групп в исходных данных'
| group | A | B |
|---|---|---|
| user_id | 2674 | 1999 |
'Размеры групп в очищенных данных'
| group | A | B |
|---|---|---|
| user_id | 1889 | 1396 |
print('разница в размерах групп исходных данных:', group_for_funnel_base['A']/group_for_funnel_base['B'] * 100)
print('разница в размерах групп очищенных данных:', group_for_funnel_clear['A']/group_for_funnel_clear['B'] * 100)
разница в размерах групп исходных данных: user_id 133.77 dtype: float64 разница в размерах групп очищенных данных: user_id 135.32 dtype: float64
В исходном датасете присутствуют 1602 пользователя, учавствовавшие в двух тестах. Пользователи, принимавшие участие одновременно в группах А и В, отсутствуют.
В очищенном датасете участники двух тестов. Тем не менее пропорции групп в обоих случаях сильно отличаются: в первом случае группа А больше группы В на 33%, во втором на 35%
Ранее, при анализе воронки, мы видели, что наши ключевые метрики (конверсии в события) по группам различаются более чем на 1%. Это может говорить о том, что результаты уже заметны и без А/В теста.
В данных теста присутствуют факторы, которые могут исказить результат тестирования:
В группах теста видна значительная разница в пропорциях: группа А больше группы В почти на 34% в исходных данных и на 35% в очищеных.
В исходном датасете присутствуют 1602 пользователя, учавствовавшие в двух тестах. Поскольку мы не можем однозначно идентифицировать к какому тесту относятся их события, данные пользователи могут исказить результат.
В исходном датасете присутствуют пользователи, которые не относятся к целевой аудитории (региону EU), что также может вести к искажению результата теста
Наши ключевые метрики (конверсии в события) по группам различаются более чем на 1%. Это может говорить о том, что результаты уже заметны и без А/В теста.
Исходя из анализа результатов тестирования можно сказать, что внесенные в группу В изменения не повлияли на воронку положительно: речь не идет о 10% увеличении конверсии на каждом этапе воронки: мы говорим о глобальном снижении конверсии практически на всех этапах кроме конверсии страницы товара в корзину, где есть 3% увеличения, но и оно также нивелируется последующим падением конверсии корзины в покупку.
Теоретически уже понятнен результат, но все же проверим статистическую разницу долей z-критерием.
Для проведения теста сформулируем гипотезы:
Применим для групп Z-тест на равенство долей. Размеры наших групп превышает 2 тыс. пользователей на первом этапе, но затем уменьшаются до размера не менее 100. Коэффициент статистической значимости примем равным 0.01.
def z_test (funnels, alpha, event):
# критический уровень статистической значимости
alpha = alpha
#значения выборок на уровне тестируемого события
successes_1 = funnels.loc[event, 'A']
successes_2 = funnels.loc[event, 'B']
#первоначальные значения выборок
trials_1 = funnels.loc['user_reg', 'A']
trials_2 = funnels.loc['user_reg', 'B']
# пропорция успехов в первой группах:
p1 = successes_1/trials_1
p2 = successes_2/trials_2
# пропорция успехов в комбинированном датасете:
p_combined = (successes_1 + successes_2) / (trials_1 + trials_2)
# разница пропорций в датасетах
difference = p1 - p2
# считаем статистику в ст.отклонениях стандартного нормального распределения
z_value = difference / mth.sqrt(p_combined * (1 - p_combined) * (1/trials_1 + 1/trials_2))
# задаем стандартное нормальное распределение (среднее 0, ст.отклонение 1)
distr = st.norm(0, 1)
p_value = (1 - distr.cdf(abs(z_value))) * 2
#return p_value
if p_value < alpha:
return [event, p_value, 'отвергаем Н0']
else:
return [event, p_value, 'не отвергаем Н0']
Применим z-test для исходных и очищенных групп тестирования.
#для исходных групп
for name_event in ['product_page', 'product_cart', 'purchase']:
print(z_test(funnel_base, 0.01, name_event))
['product_page', 0.0, 'отвергаем Н0'] ['product_cart', 0.0, 'отвергаем Н0'] ['purchase', 0.0, 'отвергаем Н0']
#для очищенных групп
for name_event in ['product_page', 'product_cart', 'purchase']:
print(z_test(funnel_clear, 0.01, name_event))
['product_page', 0.0, 'отвергаем Н0'] ['product_cart', 0.0, 'отвергаем Н0'] ['purchase', 0.0, 'отвергаем Н0']
На всех уровнях воронки событий при проведении z-теста гипотезу о равенстве групп не удалось подтвердить. Конверсии с большой степенью вероятности отличаются: и в исходном, и в очищенном датасетах.
Гипотезы о равенстве конверсий в группах А и В не удалось подтвердить. Это означает, что они различаются. Мы подтвердили выводы, сделанные в результате исследовательского анализа.
Для анализа результатов А/В теста был представлен датасет с действиями пользователей, техническое задание и несколько вспомогательных датасетов c результатами тестирования. В процессе предобработки была произведена замена формата данных, содержащих дату.
Результаты исследования:
К заданному тесту recommender_system_test относится 6701 запись о пользователях: 3824 в группе А и 2877 в группе В.
Оценка корректности проведения теста:
Дата начала и окончания набора пользователей 7 декабря и 21 декабря 2020 года соответствует техническому заданию.
Тест проводился с 7 декабря по 30 декабря. Дата остановки теста меньше, чем указано в тех.задании: 30 декабря вместо 4 января. Здесь повлияли новогодние праздники.
В момент проведения теста в Европе проводилась промо-акция 'Christmas&New Year Promo'. Понять, что именно изменения вызывают улучшения, а не параллельно проводимая акция, в таком случае крайне затруднительно.
Выявлены 1602 уникальных пользователя, которые участвовали в проверяемом (recommender_system_test) и конкурирующем (interface_eu_test) тестах одновременно. Поскольку события в датасете events не позволяют отнести событие к одному из тестов, оставляя этих пользователей в тесте мы можем существенно исказить результаты.
Внутри групп теста пересечений пользователей нет
Группы разбиты неравномерно: в контрольной 57% пользователей, в тестовой 43%.
Состав аудитории теста: исходное количество участников - 6701, из которых 6351 из Европы и 350 из других регионов.
Согласно техническому заданию аудитория должна состоять из 15% новых зарегистрированных пользователей из Европы, расчетное количество участников - 6000 пользователей. Фактическое количество участников составило 6940 человек, а фактически набрано только 13.73% аудитории из Европы.
Мы удалили пользователей из других регионов, которые не были предусмотрены техническим заданием.
Таким образом, в исходной группе нашего теста осталось 4763 пользователя (активных и неактивных) (группа А 2674, группа В 1999 пользователей), в "очищенной"(контрольной группе, где мы производим корректировки на выявленные несоответствия тех.заданию, а именно удалили 1602 пользователя-участника двух тестов и 350 пользователей, не относящихся к региону EU)-3285 (А- 1889, В- 1396) )
Эффект от изменений, судя по воронке, не достигнут: конверсия регистрации в покупку в группе В упала в 2 раза по сравнению с группой А.
Исследовательский анализ
Соотношение активных и неактивных пользователей в группах сильно отличаются: неактивных в группе А 40%, а в группе B 66%. Если смотреть на распределение количества событий в целом по группам (активных и неактивных пользователей в совокупности), то в группе А пик приходится на 7 событий, а в группе В - на 4 и 6. Среднее значение количества событий в группе В в 2 раза меньше, чем в группе А (2 против 4).
Отличается и медиана: в группе А она на уровне 2.5 событий, а выбросы начинаются с 15 событий. В группе В медиана равна 0, а выбросы начинаются уже с 7 событий.
Если события распределить только между активными пользователями, среднее в группе А составляет 7, а в группе В 6 событий, а медианные значения соответственно 6 и 5 событий.
То есть группа А в целом активнее группы В, а разница событий на пользователя в группе В меньше, чем в группе А на 53%.
Согласно тех.заданию ожидаемым эффектом от тестируемых изменений является улучшение каждой метрики не менее, чем на 10%, за 14 дней с момента регистрации. Фактически:
конверсия событий регистрации в страницу товара уменьшилось на 18% на исходных данных и на 19% на очищенных конверсия страницы товара в корзину увеличилась на 3% в исходных данных и на 4% в очищенных конверсия корзины в покупку уменьшилась почти на 5% в исходных данных и осталась на старом уровне в очищенных. Если смотреть конверсию регистрации в корзину, то в целом она уменьшилась на почти 9% в исходных данных (с 18% до 9%) и на 8% (с 17% до 9%) в очищенных.
Больше всего пользователей теряется на этапе перехода к странице товара: до нее доходит только 38% пользователей группы А и 19% пользователей группы В. Но те, кто доходит до корзины, в подавляющем большинстве платит за покупку. Можно заметить, что в группе В есть 3% увеличение пользователей, дошедших до корзины, но он нивелируется процентом так и не оплативших покупку.
Таким образом, результатом тестирования не подтвердилось увеличение конверсии на каждом этапе: конверсия увеличилась только на этапе перехода с регистрации на страницу товара.
Факторы, которые могут исказить результат тестирования:
В группах теста видна значительная разница в пропорциях: группа А больше группы В почти на 34% в исходных данных и на 35% в очищеных.
В исходном датасете присутствуют 1602 пользователя, учавствовавшие в двух тестах. Поскольку мы не можем однозначно идентифицировать к какому тесту относятся их события, данные пользователи могут исказить результат.
В исходном датасете присутствуют пользователи, которые не относятся к целевой аудитории (региону Европы), что также может вести к искажению результата теста
Кроме того, наши ключевые метрики (конверсии в события на разных уровнях воронки) по группам различаются более чем на 1%. Это может говорить о том, что результаты уже заметны и без А/В теста.
Общий результат
При проведении А/В тестирования z-критерием гипотезы о равенстве конверсий в группах А и В не удалось подтвердить. Это означает, что они различаются. Мы подтвердили выводы, сделанные в результате исследовательского анализа: конверсии различаются, и не в лучшую сторону, следовательно предлагаемые к внесению изменения, которые были целью тестирования, не будут эффективными.
Мы провели анализ на исходных данных и данных, которые были скорректированы на выявленные факторы, способные привести к искажению результатов (удалили пользователей-участников двух тестов, а также пользователей из других регионов). Очистка прошла равномерно по всему датасету и поэтому почти не изменила характер поведения пользователей. Поэтому с определенной долей вероятности можно утверждать, что результаты теста соответствуют действительности. Тем не менее ошибок при проведении эксперимента допущено много, а время его проведения выбрано крайне неудачное (захвачен период промоакции), поэтому доверять его результатам рискованно.